<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>virtual List</title>
		<style>
			* {
				margin: 0;
				padding: 0;
				box-sizing: border-box;
			}
			#virtual-scroller {
				background-color: #f0f2f7;
				overflow: auto; 
			}
			.row-content {
				height: 100%;
				display: flex;
				align-items: center;
				justify-content: center;
				font-size: 36px;
				border-bottom: 1px solid #c4c4c4;
			}
		</style>
	</head>
	<body>
		<div id="virtual-scroller"></div>
		<script>
			class VirtualScroller {
				constructor({
					element,
					height,
					rowHeight,
					pageSize,
					renderItem,
					loadMore
				}) {
					if (typeof element === 'string') {
						this.scroller = document.querySelector(element)
					} else if (element instanceof	HTMLElement) this.scroller = element
					if (!this.scroller) {
						throw new Error('不是一个元素')
					}
					if (!height || (typeof height !== 'number' && typeof height !== 'string')) {
						throw new Error('高度无效或没有设置高度')
					}
					if (!rowHeight || (typeof rowHeight !== 'number' && typeof rowHeight !== 'string')) {
						throw new Error('行高无效或没有设置行高')
					}
					if (typeof renderItem !== 'function') {
						throw new Error('renderItem 不是一个函数')
					}
					if (typeof loadMore !== 'function') {
						throw new Error('loadMore 不是一个函数')
					}
					
					this.height = height
					this.rowHeight = rowHeight
					this.pageSize = pageSize > 0 ? pageSize : 50
					this.renderItem = renderItem
					this.loadMore = loadMore
					this.data = []
					
					// create content box
					const contentBox = document.createElement('div')
					this.contentBox = contentBox
					this.scroller.append(contentBox)
					
					this.scroller.style.height = typeof height === 'number' ? height + 'px' : height;
					this.scroller.addEventListener('scroll', this.#handleScroll)
				
					// loadInitData 初始化
					this.#loadInitData()
				}
				
				#scrollTop = 0
				#topHiddenCount = 0
				#bottomHiddenCount = 0
				
				// handleScroll 监听滚动的事件
				#handleScroll = e => {
					// console.log('scroll', e)
					const { clientHeight, scrollHeight, scrollTop } = e.target
					const distanceToBottom = scrollHeight - (clientHeight  + scrollTop)
					// 如果页面距离底部小于50px
					if (distanceToBottom < 50) {
						console.log('load more');
						// 载入新数据
						const newData = this.loadMore(this.pageSize)
						// console.log(newData);
						this.data.push(...newData)
						// 渲染行
						this.#renderRow(newData[0])
					}
					
					/**
					 * 判断是往上还是往下
					 * 1 是往下
					 * -1 是往上
					 */
					const direction = scrollTop > this.#scrollTop ? 1 : -1;
					this.#scrollTop = scrollTop
					// toggleTopItems 滚动时触发顶部行的操作
					this.#toggleTopItems(direction)
					// toggleBottomItems 滚动时触发底部的操作
					this.#toggleBottomItems(direction)
				}
				// toggleTopItems 滚动时触发顶部行的操作
				#toggleTopItems = (direction) => {
					const { scrollTop } = this.scroller
					// 在页面顶部的第一行 = 滚动上去的距离 / 行高
					const firstVisibleItemIndex = Math.floor(scrollTop / this.rowHeight)
					const row = this.contentBox.children
					// 删除掉元素
					if (direction === 1) {
						for (var i = this.#topHiddenCount; i < firstVisibleItemIndex; i++) {
							if (row[0]) row[0].remove()
						}
					}
					if (direction === -1) {
						// 隐藏掉的盒子数量 > 当前第一个 就添加新盒子
						for (var i = this.#topHiddenCount - 1; i >= firstVisibleItemIndex; i--) {
							const item = this.data[i] // 获取数据
							const row = this.#renderRow(item) // 渲染行
							this.contentBox.prepend(row) // 填充到页面
						}
					}
					// 填充高度
					this.#topHiddenCount = firstVisibleItemIndex
					const paddingTop = this.#topHiddenCount * this.rowHeight
					this.contentBox.style.paddingTop = paddingTop + 'px'
				}
				// toggleBottomItems 滚动时触发底部的操作
				#toggleBottomItems = (direction) => {
					const { scrollTop, clientHeight } = this.scroller
					// 找出最底部的行  = (卷去的高度 + 可见的高度) / 行数
					const lastVisibleItemIndex = Math.floor((scrollTop + clientHeight) / this.rowHeight)
					// 拿到数据
					const rows = [...this.contentBox.children]
					// 向上滚
					if (direction === -1) {
						for (var i = lastVisibleItemIndex + 1; i < this.data.length; i++) {
							// 删除顶部第一个盒子
							const row = rows[i - this.#topHiddenCount]
							if (row) row.remove()
						}
					}
					// 向下滚
					if (direction === 1) {
						// 渲染新盒子
						for (var i = this.#topHiddenCount + rows.length; i <= lastVisibleItemIndex; i++) {
							const item = this.data[i]
							const row = this.#renderRow(item)
							this.contentBox.append(row)
						}
					}
					
					// 底部隐藏的数量 = 数据的长度 - 顶部隐藏的盒子 - 当前显示的盒子 
					
					this.#bottomHiddenCount = this.data.length - (this.#topHiddenCount + this.contentBox.children.length)
					const paddingBottom = this.#bottomHiddenCount * this.rowHeight + 'px'
					this.contentBox.style.paddingBottom = paddingBottom
				}
				// renderRow 创建行
				#renderRow = item => {
					// renderItem 渲染内容，外部传入
					const rowContent = this.renderItem(item)
					const row = document.createElement('div')
					row.style.height = this.rowHeight + 'px'
					row.dataset.index = item
					row.appendChild(rowContent)
					return row
				}
				
				#renderNewData = newData => {
					newData.forEach(item => {
						// 添加到页面
						this.contentBox.append(this.#renderRow(item))
					})
				}
				
				#loadInitData = () => {
					// loadMore 加载数据
					const newData = this.loadMore(this.pageSize)
					this.data.push(...newData)
					// renderNewData 渲染新数据到页面
					this.#renderNewData(newData)
				}
			}
			const virtualScroller = new VirtualScroller({
				element: '#virtual-scroller',
				height: '100vh',
				rowHeight: 60,
				renderItem(dataItem) {
					const div = document.createElement('div')
					div.classList.add('row-content')
					div.textContent = dataItem
					return div
				},
				loadMore(pageSize) {
					const data = []
					for (var i = 0; i < pageSize; i++) {
						const dataItem = `I'm number ${this.data.length + i}`
						data.push(dataItem)
					}
					return data
				}
			});
			
			控制台性能测试代码
			const doc = document.querySelector('#virtual-scroller')
			 setInterval(() => {
							doc.scrollTo(doc.scrollTop + 50,doc.scrollTop + 50)
						}, 10)
			
		
		</script>
	</body>
</html>